Added webhook handler for gift subscription purchases#27169
Conversation
WalkthroughIntroduces end-to-end gift-purchase support: a Gift domain model and expiry constant, a Bookshelf Gift model with destroy protection, a Bookshelf repository, a GiftService with wrapper and singleton entrypoint, and unit tests for Gift.fromPurchase and GiftService.recordPurchase. Boot now initializes the gifts service. Stripe integration is updated to inject the gift service and route checkout.session webhook events containing gift metadata to a new handleGiftEvent that calls recordPurchase. 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (5)
ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts (2)
3-4: Consider simplifying redundant type aliases.Per static analysis,
BookshelfModelInstanceandBookshelfOptionsare just aliases forunknown. While they add semantic intent, they could be inlined for simplicity.Proposed simplification
-type BookshelfModelInstance = unknown; -type BookshelfOptions = unknown; -type BookshelfModel<T extends BookshelfModelInstance> = { - add(data: Partial<T>, unfilteredOptions?: BookshelfOptions): Promise<T>; - findOne(data: Record<string, unknown>, unfilteredOptions?: BookshelfOptions): Promise<T | null>; +type BookshelfModel<T> = { + add(data: Partial<T>, unfilteredOptions?: unknown): Promise<T>; + findOne(data: Record<string, unknown>, unfilteredOptions?: unknown): Promise<T | null>; };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around lines 3 - 4, Remove the two redundant type aliases BookshelfModelInstance and BookshelfOptions (both aliased to unknown) and inline unknown where they are used, or replace their usages with the concrete/appropriate type if available; update the file's type annotations referencing BookshelfModelInstance and BookshelfOptions (e.g., function signatures, variable declarations) to use unknown (or the real type) and delete the type alias declarations to simplify the code.
12-17: Mark#Modelasreadonly.The private field is never reassigned after construction.
Proposed fix
export class GiftBookshelfRepository { - `#Model`: GiftBookshelfModel; + readonly `#Model`: GiftBookshelfModel;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around lines 12 - 17, The private field `#Model` in class GiftBookshelfRepository is never reassigned after construction; mark it readonly to reflect immutability. Update the field declaration in GiftBookshelfRepository to be readonly (e.g., readonly `#Model`: GiftBookshelfModel) and ensure the constructor still assigns this.#Model = GiftModel and that no other code reassigns `#Model`. This change targets the private field named `#Model` and the constructor parameter GiftModel in the GiftBookshelfRepository class.ghost/core/core/server/services/gifts/gift.ts (1)
3-4: Consider exporting the type aliases for external use.
GiftStatusandGiftCadencetypes are defined but not exported. If other modules need to reference these types (e.g., for type-safe status checks or API contracts), consider adding theexportkeyword.♻️ Optional: Export type aliases
-type GiftStatus = 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded'; -type GiftCadence = 'month' | 'year'; +export type GiftStatus = 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded'; +export type GiftCadence = 'month' | 'year';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift.ts` around lines 3 - 4, The type aliases GiftStatus and GiftCadence are currently module-private; export them so other modules can import and use these types. Update the declarations for the identifiers GiftStatus and GiftCadence to be exported (add the export keyword) so they are available for external type-safe checks and API contracts, and ensure any existing imports are updated to reference the exported types.ghost/core/core/server/services/gifts/gift-service.ts (2)
22-29: Mark private fields asreadonly.Both
#giftRepositoryand#memberRepositoryare only assigned in the constructor and never reassigned. Marking them asreadonlyimproves type safety and documents intent.♻️ Proposed fix
export class GiftService { - `#giftRepository`: GiftBookshelfRepository; - `#memberRepository`: MemberRepository; + readonly `#giftRepository`: GiftBookshelfRepository; + readonly `#memberRepository`: MemberRepository; constructor({giftRepository, memberRepository}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository}) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 22 - 29, GiftService’s private fields `#giftRepository` and `#memberRepository` are only set in the constructor and should be marked readonly to express immutability; update the class field declarations for `#giftRepository` and `#memberRepository` to include readonly (while keeping their types GiftBookshelfRepository and MemberRepository) so the constructor remains the sole assignment point (constructor({giftRepository, memberRepository}) assigns them as before).
31-36: UseNumber.parseIntandNumber.isNaNfor modern best practices.The global
parseIntandisNaNfunctions work here, but theNumber.*equivalents are preferred:Number.parseIntis explicit about namespace, andNumber.isNaNavoids type coercion pitfalls.♻️ Proposed fix
async recordPurchase(data: GiftPurchaseData): Promise<boolean> { - const duration = parseInt(data.duration); + const duration = Number.parseInt(data.duration, 10); - if (isNaN(duration)) { + if (Number.isNaN(duration)) { throw new errors.ValidationError({message: `Invalid gift duration: ${data.duration}`}); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 31 - 36, In recordPurchase, replace the global calls with the Number namespace: use Number.parseInt(data.duration, 10) to parse the duration (explicit radix) and check the result with Number.isNaN(duration) instead of isNaN; update the error branch to use the new checks so the ValidationError is thrown when Number.isNaN(duration) is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ghost/core/core/boot.js`:
- Line 378: giftService.init() is synchronous but used alongside async
initializers; update GiftServiceWrapper.init to return a Promise (e.g., make it
async or return Promise.resolve()) so it consistently returns a Promise when
called from Promise.all in boot.js; change the implementation in the
GiftServiceWrapper class (method: init) to return a resolved Promise to
eliminate the SonarCloud warning and keep init semantics consistent with other
services.
---
Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts`:
- Around line 3-4: Remove the two redundant type aliases BookshelfModelInstance
and BookshelfOptions (both aliased to unknown) and inline unknown where they are
used, or replace their usages with the concrete/appropriate type if available;
update the file's type annotations referencing BookshelfModelInstance and
BookshelfOptions (e.g., function signatures, variable declarations) to use
unknown (or the real type) and delete the type alias declarations to simplify
the code.
- Around line 12-17: The private field `#Model` in class GiftBookshelfRepository
is never reassigned after construction; mark it readonly to reflect
immutability. Update the field declaration in GiftBookshelfRepository to be
readonly (e.g., readonly `#Model`: GiftBookshelfModel) and ensure the constructor
still assigns this.#Model = GiftModel and that no other code reassigns `#Model`.
This change targets the private field named `#Model` and the constructor parameter
GiftModel in the GiftBookshelfRepository class.
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 22-29: GiftService’s private fields `#giftRepository` and
`#memberRepository` are only set in the constructor and should be marked readonly
to express immutability; update the class field declarations for `#giftRepository`
and `#memberRepository` to include readonly (while keeping their types
GiftBookshelfRepository and MemberRepository) so the constructor remains the
sole assignment point (constructor({giftRepository, memberRepository}) assigns
them as before).
- Around line 31-36: In recordPurchase, replace the global calls with the Number
namespace: use Number.parseInt(data.duration, 10) to parse the duration
(explicit radix) and check the result with Number.isNaN(duration) instead of
isNaN; update the error branch to use the new checks so the ValidationError is
thrown when Number.isNaN(duration) is true.
In `@ghost/core/core/server/services/gifts/gift.ts`:
- Around line 3-4: The type aliases GiftStatus and GiftCadence are currently
module-private; export them so other modules can import and use these types.
Update the declarations for the identifiers GiftStatus and GiftCadence to be
exported (add the export keyword) so they are available for external type-safe
checks and API contracts, and ensure any existing imports are updated to
reference the exported types.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f5b8a4ab-e9e4-42ed-9881-628d7fee9f71
📒 Files selected for processing (14)
ghost/core/core/boot.jsghost/core/core/server/models/gift.jsghost/core/core/server/services/gifts/constants.tsghost/core/core/server/services/gifts/gift-bookshelf-repository.tsghost/core/core/server/services/gifts/gift-service-wrapper.jsghost/core/core/server/services/gifts/gift-service.tsghost/core/core/server/services/gifts/gift.tsghost/core/core/server/services/gifts/index.jsghost/core/core/server/services/stripe/service.jsghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.jsghost/core/core/server/services/stripe/stripe-service.jsghost/core/test/unit/server/services/gifts/gift-service.test.tsghost/core/test/unit/server/services/gifts/gift.test.tsghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
95bd649 to
68bf2ac
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ghost/core/core/server/services/gifts/gift-service.ts (1)
32-32: Specify radix forparseIntto avoid ambiguity.
Number.parseInt(data.duration)without a radix can behave unexpectedly with certain string formats (e.g., leading zeros in some engines historically treated as octal). While modern JavaScript defaults to base 10, explicitly specifying the radix is a defensive best practice.Suggested fix
- const duration = Number.parseInt(data.duration); + const duration = Number.parseInt(data.duration, 10);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-service.ts` at line 32, The parseInt call in gift-service.ts uses Number.parseInt(data.duration) without a radix; update the parsing of the duration (the const duration assignment) to explicitly specify the base (e.g., use radix 10) so the conversion is unambiguous (replace Number.parseInt(data.duration) with an explicit-radix parseInt call and keep any existing null/undefined handling around the duration variable).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Line 32: The parseInt call in gift-service.ts uses
Number.parseInt(data.duration) without a radix; update the parsing of the
duration (the const duration assignment) to explicitly specify the base (e.g.,
use radix 10) so the conversion is unambiguous (replace
Number.parseInt(data.duration) with an explicit-radix parseInt call and keep any
existing null/undefined handling around the duration variable).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a8f34645-ed5a-457e-9497-80c2f36c28df
📒 Files selected for processing (14)
ghost/core/core/boot.jsghost/core/core/server/models/gift.jsghost/core/core/server/services/gifts/constants.tsghost/core/core/server/services/gifts/gift-bookshelf-repository.tsghost/core/core/server/services/gifts/gift-service-wrapper.jsghost/core/core/server/services/gifts/gift-service.tsghost/core/core/server/services/gifts/gift.tsghost/core/core/server/services/gifts/index.jsghost/core/core/server/services/stripe/service.jsghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.jsghost/core/core/server/services/stripe/stripe-service.jsghost/core/test/unit/server/services/gifts/gift-service.test.tsghost/core/test/unit/server/services/gifts/gift.test.tsghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
✅ Files skipped from review due to trivial changes (4)
- ghost/core/core/server/services/gifts/constants.ts
- ghost/core/core/server/services/gifts/index.js
- ghost/core/test/unit/server/services/gifts/gift.test.ts
- ghost/core/core/server/services/stripe/service.js
🚧 Files skipped from review as they are similar to previous changes (4)
- ghost/core/core/server/models/gift.js
- ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
- ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
- ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
|
|
||
| const member = data.stripeCustomerId | ||
| ? await this.#memberRepository.get({customer_id: data.stripeCustomerId}) | ||
| : null; |
There was a problem hiding this comment.
This is a neat trick. I wonder if we should extend that to use the buyer email as source, instead of the stripe customer?
Example: buyer is "mike@ghost.org" → There is a free member with mike@ghost.org → attribute gift to that member?
ref https://linear.app/ghost/issue/BER-3484 When a gift checkout completes on Stripe, the webhook now persists a gift record to the database with full purchase details, buyer member resolution, and idempotency protection via checkout session ID
68bf2ac to
237f477
Compare
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
ghost/core/core/server/services/gifts/gift-service.ts (1)
31-40: Specify radix forparseIntto avoid ambiguity.
Number.parseIntwithout a radix can lead to unexpected behavior with leading zeros (e.g.,'08'in older environments). Explicitly pass10as the radix for decimal parsing.Proposed fix
- const duration = Number.parseInt(data.duration); + const duration = Number.parseInt(data.duration, 10);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 31 - 40, In recordPurchase (gift-service.ts) the call to Number.parseInt(data.duration) omits a radix; update it to parse the duration as base 10 by calling Number.parseInt(data.duration, 10) (or otherwise parse as decimal) so inputs like "08" are handled consistently; ensure this change is applied inside the recordPurchase method before the NaN check that throws the ValidationError.ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts (1)
25-47: Return type mismatch: declaredPromise<unknown>but returnsPromise<void>.The method awaits
this.#Model.add(...)but doesn't return its result, so it actually returnsPromise<void>. The declared return type should match.Proposed fix
- async create(gift: Gift) { + async create(gift: Gift): Promise<void> { await this.#Model.add({🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around lines 25 - 47, The create method declares it returns Promise<unknown> but currently awaits this.#Model.add(...) and does not return its result (so it returns Promise<void>); update the method to either return the result of this.#Model.add(...) (i.e., add a return before this.#Model.add(...) in the create function) or change the declared return type to Promise<void> to match behavior; locate the create method in gift-bookshelf-repository (method name: create) and make the return-type and actual return consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ghost/core/core/server/services/stripe/stripe-service.js`:
- Around line 115-117: The getter giftService should defensively handle the case
where giftService.service is not yet initialized to avoid runtime failures in
webhook handling; update the giftService getter to check that the module-level
giftService and giftService.service are defined and, if not, either return a
safe no-op stub (implementing the methods used by recordPurchase) or throw a
clear error so callers can bail gracefully; reference the giftService getter,
giftService.init, stripe.init and the recordPurchase path that handles
checkout.session.completed webhooks when adding this guard.
---
Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts`:
- Around line 25-47: The create method declares it returns Promise<unknown> but
currently awaits this.#Model.add(...) and does not return its result (so it
returns Promise<void>); update the method to either return the result of
this.#Model.add(...) (i.e., add a return before this.#Model.add(...) in the
create function) or change the declared return type to Promise<void> to match
behavior; locate the create method in gift-bookshelf-repository (method name:
create) and make the return-type and actual return consistent.
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 31-40: In recordPurchase (gift-service.ts) the call to
Number.parseInt(data.duration) omits a radix; update it to parse the duration as
base 10 by calling Number.parseInt(data.duration, 10) (or otherwise parse as
decimal) so inputs like "08" are handled consistently; ensure this change is
applied inside the recordPurchase method before the NaN check that throws the
ValidationError.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 68d42b00-38df-4ea3-9220-11db0b8a470e
📒 Files selected for processing (14)
ghost/core/core/boot.jsghost/core/core/server/models/gift.jsghost/core/core/server/services/gifts/constants.tsghost/core/core/server/services/gifts/gift-bookshelf-repository.tsghost/core/core/server/services/gifts/gift-service-wrapper.jsghost/core/core/server/services/gifts/gift-service.tsghost/core/core/server/services/gifts/gift.tsghost/core/core/server/services/gifts/index.jsghost/core/core/server/services/stripe/service.jsghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.jsghost/core/core/server/services/stripe/stripe-service.jsghost/core/test/unit/server/services/gifts/gift-service.test.tsghost/core/test/unit/server/services/gifts/gift.test.tsghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
✅ Files skipped from review due to trivial changes (7)
- ghost/core/core/server/services/gifts/index.js
- ghost/core/core/server/models/gift.js
- ghost/core/core/server/services/gifts/constants.ts
- ghost/core/core/server/services/gifts/gift-service-wrapper.js
- ghost/core/test/unit/server/services/gifts/gift.test.ts
- ghost/core/core/server/services/gifts/gift.ts
- ghost/core/test/unit/server/services/gifts/gift-service.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- ghost/core/core/boot.js
- ghost/core/core/server/services/stripe/service.js
- ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
- ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
| get giftService(){ | ||
| return giftService.service; | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Verify boot/service init ordering for gifts vs stripe
fd -i "boot.js" ghost/core/core
rg -n -C4 "services|init|gifts|stripe" ghost/core/core/boot.js
# 2) Verify gifts export/init shape
fd -i "index.js" ghost/core/core/server/services/gifts
rg -n -C4 "module\\.exports|GiftServiceWrapper|init\\(" ghost/core/core/server/services/gifts/index.js
rg -n -C4 "class GiftServiceWrapper|service;|async init\\(" ghost/core/core/server/services/gifts/gift-service-wrapper.js
# 3) Verify call site assumes concrete service methods
rg -n -C4 "giftService|recordPurchase" ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.jsRepository: TryGhost/Ghost
Length of output: 19001
🏁 Script executed:
# Check when stripe service registers webhooks
rg -n -C5 "webhook|registerWebhook|handleEvent" ghost/core/core/server/services/stripe/stripe-service.js | head -60
# Check if webhooks are registered during init() or later
rg -n -C3 "registerWebhook|listen" ghost/core/core/server/services/stripe/index.js
# Verify the actual webhook event flow - when is the getter accessed?
rg -n -B5 -A5 "giftService.*recordPurchase|deps\.giftService" ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.jsRepository: TryGhost/Ghost
Length of output: 3907
🏁 Script executed:
# Check when webhooks are actually registered/started in stripe init
rg -n -B10 -A10 "webhookManager\|WebhookManager\|webhooks.listen" ghost/core/core/server/services/stripe/index.js
# Check if there's any middleware or guard before webhook handling
rg -n "webhookController" ghost/core/core/server/services/stripe/stripe-service.js
# Check if giftService is checked for null/undefined before use
rg -n -B2 -A2 "giftService\." ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
# Check the webhook event dispatcher - does it await initialization?
fd -i "webhook-controller.js" ghost/core/core/server/services/stripe
rg -n -B5 -A10 "class WebhookController\|async.*handle\|handle.*event" ghost/core/core/server/services/stripe/webhook-controller.js | head -80Repository: TryGhost/Ghost
Length of output: 504
🏁 Script executed:
# Get the full webhook controller file to understand webhook handling
cat -n ghost/core/core/server/services/stripe/webhook-controller.js | head -150
# Check how stripe service initializes and when webhooks start listening
cat -n ghost/core/core/server/services/stripe/index.js | head -100Repository: TryGhost/Ghost
Length of output: 6143
🏁 Script executed:
# Get the stripe service init logic
cat -n ghost/core/core/server/services/stripe/service.js | head -200
# Also check when the webhook route is registered - look for router/app setup
rg -n "webhook\|stripe.*route" ghost/core/core/server/web -A3 -B3 | head -80Repository: TryGhost/Ghost
Length of output: 3211
🏁 Script executed:
# Check how CheckoutSessionEventService is instantiated and when giftService is passed to it
rg -n -B5 -A5 "CheckoutSessionEventService" ghost/core/core/server/services/stripe/stripe-service.js
# Check the full checkout-session-event-service to see how it uses giftService
cat -n ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.jsRepository: TryGhost/Ghost
Length of output: 15712
🏁 Script executed:
# Get lines 101-120 from stripe-service.js to see the full CheckoutSessionEventService instantiation
sed -n '101,120p' ghost/core/core/server/services/stripe/stripe-service.js
# Also get the complete constructor of StripeService to see when giftService is stored
sed -n '50,120p' ghost/core/core/server/services/stripe/stripe-service.jsRepository: TryGhost/Ghost
Length of output: 2795
Add a guard to ensure giftService is initialized before accessing its methods during webhook handling.
The race condition is real: stripe.init() completes and awaits on line 347 of boot.js, but giftService.init() runs concurrently in a Promise.all without awaiting. Stripe webhooks can arrive immediately after initialization, before giftService.service is populated. If a checkout.session.completed webhook with gift metadata arrives before giftService.init() finishes, the getter at lines 115-117 returns undefined, causing recordPurchase() to fail at runtime.
Either ensure giftService.init() is awaited before stripe webhooks become active, or add a defensive check in the getter to guard against uninitialized state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ghost/core/core/server/services/stripe/stripe-service.js` around lines 115 -
117, The getter giftService should defensively handle the case where
giftService.service is not yet initialized to avoid runtime failures in webhook
handling; update the giftService getter to check that the module-level
giftService and giftService.service are defined and, if not, either return a
safe no-op stub (implementing the methods used by recordPurchase) or throw a
clear error so callers can bail gracefully; reference the giftService getter,
giftService.init, stripe.init and the recordPurchase path that handles
checkout.session.completed webhooks when adding this guard.



ref https://linear.app/ghost/issue/BER-3484
When a gift checkout completes on Stripe, the webhook now persists a gift record to the database with full purchase details, buyer member resolution, and idempotency protection via checkout session ID